リクエスト署名を検証するAPIをContentfulアプリから呼び出してブログの下書きをチェックしてみた
リテールアプリ共創部@大阪の岩田です。
先日こんな記事を書きました。
この記事で紹介しているAPIは実際にDevelopersIOの記事を執筆する際にVS Codeのプラグインから呼び出してブログの下書きをチェックするために利用されています。ただ、VS Codeのプラグインを利用する形だとVS Code以外の執筆環境を利用している人はこのAPIの恩恵を受けられないという課題があります。
そこでDevelopersIOのバックエンドで利用されているContentfulのアプリを自前で開発してContentfulの編集画面と統合できないか検証してみました。
参考までにContentful含めたDevelopersIOの基盤については以下の動画で詳しく紹介されているので、興味があればご視聴ください。
概要
こんな感じの構成を作ります。
最終的にこうなります。
処理の流れは以下の通りです。
- ユーザーがContentfulの編集画面を開く
- Contentfulのコンテンツに加えて自作ContentfulアプリのHTMLやJSファイルも返却される
- 自作ContentfulアプリのJSが実行され、リクエスト署名を付与してレビュー用のAPIが呼び出される
- API GatewayがLambda Authorizerを呼び出す
- Lambda AuthorizerはAPI Gatewayからリクエストヘッダを受け取り、対象Contentfulアプリの署名として妥当かを検証する
- 検証結果がOKの場合は後続処理へ...
APIを呼び出せるのはContenfulにログインしているユーザーに限定するため、Contenfulのアプリからリクエストを発行する際は署名を付与してもらい、この署名をLambda Authorizerにて検証します。この署名を付与/検証する機構がContetful側で用意されているので、うまくこの機構に乗っかりましょう。
環境
今回利用した環境は以下のとおりです。
- バックエンドAPI
- Node.js: 20x
- @contentful/node-apps-toolkit: 3.9.0
- Contentfulアプリ
- @contentful/app-sdk: 4.29.1
- @contentful/f36-components: 4.74.0
- @contentful/f36-tokens: 4.1.0
- @contentful/react-apps-toolkit: 1.2.16
- contentful-management: 10.46.4
Contenfulアプリの作成
まずContentfulのアプリを作成します。詳細な手順は割愛するので、必要に応じて以下のブログなどを参照してください。
今回はブログ記事の下書きをAPIでレビューしたいので、LocationsにはEntry editor
を選択しておきましょう。
Lambda Authorizerでリクエストを検証できるようにSigning secret
を作成しておきます。
アプリが作成できたらアプリを利用するスペースにインストールしておきましょう。
バックエンドAPIの作成
ここからアプリを実装していきます。まずはバックエンドのAPIです。
SAMテンプレート
以下のSAMテンプレートで関連リソースをデプロイします。
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
contentful-backend-api
Globals:
Function:
Timeout: 3
Runtime: nodejs20.x
Tracing: Active
Api:
TracingEnabled: true
Parameters:
ContentfulRequestSignSecret:
Type: String
Resources:
Api:
Type: AWS::Serverless::Api
Properties:
StageName: prd
DefinitionBody:
Fn::Transform:
Name: AWS::Include
Parameters:
Location: oas.yaml
Auth:
Authorizers:
authorizer:
FunctionArn: !GetAtt AuthorizerFunction.Arn
FunctionPayloadType: REQUEST
Identity:
ReauthorizeEvery: 0
Headers:
- X-Contentful-Signature
AuthorizerFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: authorizer/
Handler: app.lambdaHandler
Architectures:
- arm64
Environment:
Variables:
CONTENTFUL_REQUEST_SIGN_SECRET: !Ref ContentfulRequestSignSecret
Metadata:
BuildMethod: esbuild
BuildProperties:
Minify: true
Target: es2020
Sourcemap: true
EntryPoints:
- app.ts
パラメータのContentfulRequestSignSecret
には先ほどContentfulで作成した署名用のシークレットを渡します。渡されたシークレットはLambda Authorizerで利用するLambdaの環境変数にセットします。実際に業務で利用する場合はSSM Parameter StoreやSecrets Managerを利用するように修正した方が良いですが、今回は検証目的なので手抜きします。
Open APIの定義
SAMテンプレートから読み込んでいるOpen APIの定義は以下の通りです。
openapi: 3.0.1
info:
title:
Blog Review API
version: '1.0'
paths:
/:
post:
responses:
"200":
description: "200 response"
headers:
Access-Control-Allow-Origin:
schema:
type: "string"
content:
application/json:
schema:
type: object
properties:
typo:
type: string
example: タイポ警察だ!
mediaPolicy:
type: string
example: 特に問題ありません
security:
- authorizer: []
x-amazon-apigateway-integration:
responses:
default:
statusCode: "200"
responseParameters:
method.response.header.Access-Control-Allow-Origin: "'*'"
responseTemplates:
application/json: |
{
"typo": "タイポ警察だ!",
"mediaPolicy": "特に問題ありません"
}
requestTemplates:
application/json: "{\"statusCode\": 200}"
passthroughBehavior: "when_no_match"
type: "mock"
options:
responses:
"200":
description: "200 response"
headers:
Access-Control-Allow-Origin:
schema:
type: "string"
Access-Control-Allow-Methods:
schema:
type: "string"
Access-Control-Allow-Headers:
schema:
type: "string"
content:
application/json:
schema:
type: object
x-amazon-apigateway-integration:
type: "mock"
responses:
default:
statusCode: "200"
responseParameters:
method.response.header.Access-Control-Allow-Methods: "'POST'"
method.response.header.Access-Control-Allow-Headers:
"'Content-Type,X-Contentful-Space-Id,X-Contentful-Environment-Id,X-Contentful-User-Id,X-Contentful-Signed-Headers,X-Contentful-Signature,X-Contentful-Timestamp,X-Contentful-Crn'"
method.response.header.Access-Control-Allow-Origin: "'*'"
requestTemplates:
application/json: "{\"statusCode\": 200}"
passthroughBehavior: "when_no_match"
components:
securitySchemes:
authorizer:
type: "apiKey"
name: "X-Contentful-Signature"
in: "header"
x-amazon-apigateway-authtype: "custom"
x-amazon-apigateway-authorizer:
authorizerUri:
Fn::Sub: "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${AuthorizerFunction.Arn}/invocations"
authorizerResultTtlInSeconds: 300
identitySource: "method.request.header.X-Contentful-Signature"
type: "request"
下書きレビュー用のAPIは POST /に定義しています。今回はモックレスポンスで固定のレスポンスを返却していますが、最終的にこの部分は前回紹介したStep Functions × BedrockのAPIに置き換える想定です。
ポイントとしてContentfulのアプリからAPIを呼び出せるようにCORSの設定も入れています。プリフライトリクエストに対して返却するレスポンスヘッダのAccess-Control-Allow-Headers
にはリクエストの署名に必要なX-Contentful...
系のヘッダの指定が必要です。今回は検証目的のためAccess-Control-Allow-Origin
は*としていますが、実際に業務で利用する場合は適切に制限しましょう。
また、簡略化のためにLambda Authorizerのチェックに引っかかって403エラーになった場合のレスポンスの定義を省略しています。ContentfulアプリからAPIを呼び出す際に署名の作成ミスなどで403エラーが発生するとCORSエラーが発生するので、ここも実業務で利用する際は追加実装が必要なポイントです。
Lambda Authorizerの実装
Contentfulのリクエスト署名を検証するLambda Authorizerの実装です。
import { APIGatewayRequestAuthorizerEvent, APIGatewayAuthorizerResult } from 'aws-lambda';
import { verifyRequest } from '@contentful/node-apps-toolkit';
const secret = process.env.CONTENTFUL_REQUEST_SIGN_SECRET?? '';
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
export const lambdaHandler = async (event: APIGatewayRequestAuthorizerEvent): Promise<APIGatewayAuthorizerResult> => {
const { headers } = event;
const canonicalRequest = {
path: event.requestContext.path,
headers: headers as Record<string, string>,
method: event.httpMethod as HttpMethod,
};
let isValid = false;
try {
isValid = verifyRequest(secret, canonicalRequest);
} catch (e: unknown) {
console.log(e);
}
let principalId = '';
if (headers != null) {
principalId = headers['x-contentful-user-id'] ?? '';
}
const createPolicy = (effect: 'Deny' | 'Allow') => {
return {
principalId,
policyDocument: {
Version: '2012-10-17',
Statement: [
{
Action: 'execute-api:Invoke',
Effect: effect,
Resource: event.methodArn,
},
],
}
}
}
if(isValid === false) {
return createPolicy('Deny');
}
return createPolicy('Allow');
};
署名対象のcanonicalRequest
を生成する以下のしょりですが、pathに指定するのはevent.path
ではなくevent.requestContext.path
になることに注意してください。また、Lambda Authorizerはリクエストボディにアクセスできないため、bodyも含めていません。この部分についてはContentfulアプリ側で署名を生成する際もリクエストボディは無視する必要があるので要注意です。
const canonicalRequest = {
path: event.requestContext.path,
headers: headers as Record<string, string>,
method: event.httpMethod as HttpMethod,
};
canonicalRequestさえ適切に生成できれば、あとはverifyRequest
を呼び出すだけでライブラリがよしなにやってくれます。
一通りバックエンドの準備ができたらsam build
でビルド後に sam deploy --parameter-overrides ContentfulRequestSignSecret=<Contentfulで作成した署名用のシークレット>
で一式デプロイしておきましょう。
Contentfulアプリの実装
続いてAPIを呼び出すフロントエンドのContentfulアプリを実装します。
まず以下のコマンドでアプリの雛形を作成します。
npx create-contentful-app アプリ名
今回はEntry Editorを拡張したいので、src/locations/EntryEditor.tsx
を以下のように更新します。
import React, { useState } from 'react';
import { Button, Heading, Paragraph } from '@contentful/f36-components';
import { EditorAppSDK } from '@contentful/app-sdk';
import { useSDK } from '@contentful/react-apps-toolkit';
const Entry = () => {
const sdk = useSDK<EditorAppSDK>();
const [reviewResult, setReviewResult] = useState({
typo: '',
mediaPolicy: '',
});
const review = async () => {
const contentsField = sdk.entry.fields['<ContentfulのContent modelに定義したフィールドのID>'];
const contents = contentsField.getValue().content[0].content[0].value;
const req = {
method: 'POST',
url: 'https://<バックエンドAPIのエンドポイント>/prd/',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ article: contents }),
} as const;
const { additionalHeaders } = await sdk.cma.appSignedRequest.create({
appDefinitionId: sdk.ids.app?? '',
}, {
method: req.method,
headers: req.headers,
// Lambda Authorizerはbodyを受け取れないので署名に含めない
// body: req.body,
path: new URL(req.url).pathname,
});
Object.assign(req.headers, additionalHeaders);
const res = await fetch(req.url, req);
const reviewResult = await res.json();
setReviewResult(reviewResult)
}
return <>
<Heading>タイポチェックの結果</Heading>
<Paragraph>{reviewResult.typo}</Paragraph>
<Heading>メディアポリシー準拠チェックの結果</Heading>
<Paragraph>{reviewResult.mediaPolicy}</Paragraph>
<Button onClick={review}>レビュー</Button>
</>
};
export default Entry;
アプリの利用対象とするContent Modelは以下のような定義で事前に作成しています。
contents
というIDでリッチテキストの入力欄を定義しているので、このIDを指定してsdk.entry.fields
で入力値を取得してリクエストボディにセットします。
リクエストの準備ができたら以下の部分でリクエスト署名を生成し、リクエストヘッダに付与します。
const { additionalHeaders } = await sdk.cma.appSignedRequest.create({
appDefinitionId: sdk.ids.app?? '',
}, {
method: req.method,
headers: req.headers,
// Lambda Authorizerはbodyを受け取れないので署名に含めない
// body: req.body,
path: new URL(req.url).pathname,
});
Object.assign(req.headers, additionalHeaders);
あとは下書きレビュー用のAPIを呼び出してレスポンスを画面に描画するだけです。今回は検証目的なので、エラーハンドリングは省略しています。
動作確認してみる
ここまででバックエンドAPIとContentfulアプリの準備が完了しました。Contentfulアプリをローカルで起動しつつ、簡単に動作確認してみましょう。
レビューボタンをクリックするとレビュー用のAPIにリクエストを発行し、結果を画面に表示できているのが分かります。あとはバックエンドAPIでモックレスポンスを返却している部分をStep Functions & Bedrockと統合したり、フロントエンドの見栄えを調整したり、諸々手抜きしている箇所を仕上げればうまく実運用できそうな感触です。
最後に開発者ツールからCopy as cURL
でコマンドをコピーし、リクエストヘッダのx-contentful-signature
を一部書き換えてターミナルに貼り付けてみましょう。
❯ curl 'https://<APIのエンドポイント>/prd/' \
∙ -H 'accept: */*' \
∙ -H 'accept-language: ja,en-US;q=0.9,en;q=0.8' \
∙ -H 'content-type: application/json' \
∙ -H 'origin: http://localhost:3000' \
∙ -H 'priority: u=1, i' \
∙ -H 'referer: http://localhost:3000/' \
∙ -H 'sec-ch-ua: "Google Chrome";v="129", "Not=A?Brand";v="8", "Chromium";v="129"' \
∙ -H 'sec-ch-ua-mobile: ?0' \
∙ -H 'sec-ch-ua-platform: "macOS"' \
∙ -H 'sec-fetch-dest: empty' \
∙ -H 'sec-fetch-mode: cors' \
∙ -H 'sec-fetch-site: cross-site' \
∙ -H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36' \
∙ -H 'x-contentful-crn: crn:contentful:::extensibility:spaces/...略' \
∙ -H 'x-contentful-environment-id: master' \
∙ -H 'x-contentful-signature: ...略' \
∙ -H 'x-contentful-signed-headers: content-type,x-contentful-crn,x-contentful-environment-id,x-contentful-signed-headers,x-contentful-space-id,x-contentful-timestamp,x-contentful-user-id' \
∙ -H 'x-contentful-space-id: ...略' \
∙ -H 'x-contentful-timestamp: 1729153084089' \
∙ -H 'x-contentful-user-id: ...略' \
∙ --data-raw '{"article":"これはテストです"}'
以下のようなレスポンスが返却されました。
{"Message":"User is not authorized to access this resource with an explicit deny"}
Lambda Authorizerで正しくリクエストに署名が検証できてそうですね。
まとめ
ブログの下書きをレビューするアプリをContentfulのアプリと統合してみました。リクエスト署名を検証する機構とLambda Authorizerを組み合わせることで未認証ユーザーからのAPI呼び出しを防止できるのが便利ですね。